Skip to content

feat(linear): mint OAuth tokens via AgentCore Identity Token Vault, and latch revoked credentials (#809, #812) - #831

Merged
isadeks merged 86 commits into
mainfrom
feat/809-linear-identity-vault-pr
Sep 4, 2026
Merged

isadeks merged 86 commits into
mainfrom
feat/809-linear-identity-vault-pr

Conversation

@isadeks

@isadeks isadeks commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Closes #809
Closes #812

Mints Linear OAuth tokens through the AgentCore Identity Token Vault instead of storing a long-lived credential, and detects/latches/announces a revoked channel credential instead of surfacing it as a failed user task.

Both issues ship together because the code is interdependent: #812's typed VaultTokenResult is what the vault resolver returns, and #809's recorded vault subject flows through #812's revocation recorder. Splitting them would mean reordering commits that touch the same files for a boundary a reviewer does not benefit from.

Flag-gated on --context enableLinearIdentityVault=true (default off). With the flag off the synthesized template contains none of it — asserted by test.

What changes for an operator

bgagent linear setup <slug> is now the only Linear onboarding command, and it needs one consent.

bgagent linear setup <slug>
  • Uses the vault when the deployment has one, Secrets Manager otherwise, announced in one line rather than requiring a flag to be known up front
  • Consent runs through a hosted page openable in any browser, so onboarding works from a cloud desktop, SSH session or container — the previous localhost loopback could not
  • vault-setup, --hosted and --localhost are gone; there is nothing left to choose
  • app-template resolves the URLs it used to demand: the consent page from a stack output, the vault callback from the provider, and the webhook endpoint from the configured API URL

The design decision worth reviewing

The vault subject is derived from the workspace slug, not its organization UUID, and recorded on the registry row as vault_user_id.

The UUID is only knowable after a token exists. Deriving the subject from it therefore forces two consents — one to learn the organization, a second to bind the vault. A slug-derived subject is known before consent, which is what makes single-consent onboarding possible. Both resolvers prefer the recorded value and fall back to the derived form for workspaces onboarded before it was stored, so existing vault installs keep minting under the subject their grant is actually under.

Two deliberate divergences from ADR-016 P1, both noted in the ADR:

  1. Per-workspace credential provider (bgagent-linear-oauth-<slug>) rather than one shared provider with per-workspace subjects. Keeps each workspace's grant and rotation isolated. Cost: each workspace adds one redirect URI to the Linear app.
  2. The agent self-mints via boto3 on both substrates rather than receiving an injected workload token.

Known limitations, stated plainly

  • ECS substrate is live-unverified. The env and task-role grant are wired and unit-tested, but no ECS task definition existed on the test deployment. The agent's mint path is substrate-uniform by design.
  • +9 CloudFormation resources with the flag on — 450 → 459 of the hard 500 limit, re-measured on this branch after fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 reclaimed 30. The consent page is in a nested stack so it costs the root one resource.
  • The vault and the MicroVM substrate are still refused together, and that guard is now obsolete. It was written when the pair synthesized 505 against a hard 500. After fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 the same pair measures 476 (483 with the tool gateway), so the combination fits and the fail-fast in cdk/src/stacks/agent.ts now refuses a deployable configuration while quoting a figure that is no longer true. The vault was always wired for MicroVM — platform config carries the workload name, the guest execution role holds the mint grant. Note the covering test asserts the throw, so it pins the guard rather than detecting the headroom; it did not and could not go red when the room appeared. Removing the guard, and adding enableLinearIdentityVault to the resource-budget matrix fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 introduced so the combination is genuinely guarded rather than merely permitted, is the follow-up.
  • The consent page URL is a generated CloudFront domain — harmless on the vault path. There it is the resourceOauth2ReturnUrl, registered on the AgentCore workload identity's allowlist, which the provisioning custom resource rewrites on every deploy; the CLI reads the current value from the LinearVaultConsentUrl output rather than caching one. So if the domain changes, nothing breaks. What Linear holds on that path is AgentCore's own callback (…/identities/oauth2/callback/<uuid>), which survives re-runs because a duplicate provider name updates in place instead of minting a new id. The instability matters only on the Secrets-Manager (non-vault) leg, where the page really is Linear's redirect_uri and a changed domain means adding the new URI to the app — both variants of that leg print the warning, and Linear accepts several URIs so the stale entry is harmless. A stable custom domain, or serving the page from the existing API Gateway, would retire even that.
  • Secrets Manager is retained on every path. Retiring it is ADR-016 P2.

Live verification

End-to-end on a real Linear workspace, with the agent-authored PR as the artifact.

Scope caveat, stated because it changes what this proves: the workspace was a FRESH install, onboarded by the merged setup command in one pass. So the vault mint, the consent flow and the revocation latch are verified — but the path a workspace takes when its Secrets Manager bundle holds no grant and was not written by this flow is covered by unit tests only. Re-verification on a genuinely fresh vault workspace is what takes this out of draft.

  • One consent, no localhost, from a cloud desktop
  • Vault-minted token verified against Linear as an actor=app agent token (…@oauthapp.linear.app), not a user token
  • A labelled issue produced: 👀 reaction, Backlog → In Progress, task created, PR-opened comment, preview screenshot — with the Linear token resolved via the vault on every hop and no Secrets Manager fallback
  • Revocation path proven in isolation: row latched status=revoked, one real SNS message, and zero duplicates on repeat events

Defects the live run caught that unit tests could not

Recorded because each is a class, not a one-off:

  1. DynamoDB Limit: 1 on a filtered Scan reported an onboarded workspace as absent — Limit caps items read before the filter.
  2. AgentCore returns ValidationException, not ConflictException, for a duplicate name. Broke re-runs, and would have broken the next deploy when the custom resource re-issued Create.
  3. IAM: GetWorkloadAccessTokenForUserId authorizes against the workload-identity directory, not the named identity; GetResourceOauth2Token against the token vault. Unit tests asserted the ARNs I had chosen, so only a live call could surface it.
  4. customParameters are part of the vault's cache key. Resolving without the consent-time set reports "needs consent" against a live grant, silently degrading every caller to Secrets Manager.
  5. Linear rejects a dead refresh token with invalid_request, not the RFC's invalid_grant, with the detail in error_description. The revocation branch had never executed.
  6. A row parser that copies fields explicitly dropped the vault subject. Every caller threaded it correctly; the parser discarded it. The test seam took three unpacked arguments and could not express the field, so no test at that seam could catch it — the seam now takes the full request.
  7. The vault grant was wired to one Lambda. Five others resolve Linear tokens; each fell back to a Secrets Manager token a vault workspace does not maintain, so a task succeeded while its issue showed nothing after the opening comment. The first guard for this pinned a hardcoded count of 4, derived by grepping handlers that import a Linear module directly — which omitted the two that reach a minting resolver two hops away, and whose count-based assertion passed for the very mistake it named. Replaced by a pinned set plus a check computed from the handler source graph.
  8. Not from the live run — from rendering the operator alert instead of reading it. The revocation email printed the same bgagent linear setup command twice on the vault path, and a .filter(line => line !== '') intended for one conditional entry stripped every blank-line separator, collapsing the message into a single block. Both were green under toContain, which cannot see a duplicate or a missing paragraph break; the assertions now pin the command count and the separators, and were mutation-checked against the old rendering.

Test plan

  • mise run build green — CDK 4411 / 210 suites, CLI 867 / 59 suites, agent 1762, docs build + link check, synth
  • security:secrets, security:deps, security:retire, security:sast clean
  • Flag-off synth asserted to contain no workload identity, no token grant, no agent env, no consent page
  • Every new guard mutation-tested — the fallback classifier, the recorded-subject preference on both resolvers, the fail-closed token preservation, the inherited-secret detection, the announce/latch ordering, the revocation self-heal, the vault-aware health check, and the registry-write and directory-ARN grants each fail when the fix is reverted
  • Live-verified end to end on a real workspace, both the vault mint and the revocation latch — see the scope caveat above
  • The narrowed IAM grants are now live-verified, and the first attempt was wrong. UpdateWorkloadIdentity authorizes against the bare workload-identity-directory/default, so it cannot be scoped to one identity; these actions do a multi-resource check and IAM names only the first resource it fails, so three successive deploys each surfaced a different AccessDenied. Final grant is all four lifecycle actions on …-directory/default plus …-directory/default/workload-identity/* — still narrower than the original account-wide workload-identity-directory/*, but the consequence is recorded in the construct: IAM cannot fence off the cross-stack allowlist clobber; the stack-derived NAME is what does. Verified by invoking the provisioning handler with an idempotent Update (previously AccessDenied, now clean), then confirming the return URLs were unchanged and the vault still mints a working actor=app token.
  • Deployable again. Deploying to a dev account had been failing before it reached any of this, for a reason unrelated to this PR: the branch carried PINNED_LOG_DELIVERY_BY_STACK while both dev accounts had moved to library-named log-delivery resources. A DeliverySource is unique per (resource ARN, log type) account-wide and the runtime ARN does not change, so a pinned id creates a second source for the same runtime and CloudFormation rolls the whole stack back with AlreadyExists. That is fix(agent): log-delivery pin table is keyed by stack name but holds per-account state, so it breaks deploys it was meant to protect #703/fix(agent): stop hard-coding log-delivery logical ids #705; dev was deployed from an integration branch carrying its pin removal, and cdk diff confirmed zero log-delivery churn before deploying.

Two pre-existing gate failures are unrelated to this branch: security:sast:masking reports on 49 files including lines that already carry justified nosemgrep comments (findings in files touched here are annotated), and security:gh-actions reports one medium in .github/workflows/auto-approve.yml, untouched here.

isadeks added 30 commits August 27, 2026 15:31
…ault (#809)

Slice 1 of RFC #249 Phase 1. Adds the stack-owned half of the Linear OAuth
token vault: a LinearIdentityVault construct that provisions the AgentCore
workload identity backing 3LO (USER_FEDERATION) token issuance, plus a
grantMintToken() helper for the token data-plane calls.

- New construct linear-identity-vault.ts + bundled custom-resource handler
  (workload-identity create/update/delete; synchronous, so onEvent-only, no
  isComplete poller). Mirrors the registry.ts Provider-framework idiom.
- Registers the return-URL allowlist with BOTH the CLI localhost loopback and
  an optional hosted onboarding URL (spike F9/F11: allowlist enforced, entries
  coexist, caller picks per-call).
- Wired into agent.ts + LinearIntegration behind the context flag
  enableLinearIdentityVault (additive, default-off; default synth byte-for-byte
  unchanged, verified 0 vault occurrences). Webhook processor gets the token
  data-plane grant + LINEAR_VAULT_ENABLED / LINEAR_WORKLOAD_IDENTITY_NAME env
  only when the vault is present.
- Bootstrap least-privilege: map Custom::LinearWorkloadIdentity to
  lambda:InvokeFunction; synth-coverage test asserts the flag-on path is fully
  bootstrap-covered (deploy-safe), matching the tool-gateway pattern.
- Adds @aws-sdk/client-bedrock-agentcore-control (control-plane sibling of the
  already-present client-bedrock-agentcore data-plane client).

Token resolution still reads the per-workspace SM token; wiring the resolver
onto the vault path is the next slice. No behavior change with the flag off.
…lback (#809)

Slice 3a of RFC #249 Phase 1 — the Lambda-side (trusted) token resolver.

- New linear-vault-token.ts: resolveLinearTokenViaVault mints a USER-BOUND
  workload token (GetWorkloadAccessTokenForUserId, user id
  linear-workspace-<orgId> per the registry-table design) then exchanges it
  (GetResourceOauth2Token, USER_FEDERATION). Returns null — never throws — on a
  consent-required response OR any API error, so the caller degrades to the SM
  token rather than blocking a task. Matches the Phase-0 spike (F2/F7).
- resolveLinearOauthToken: when LINEAR_VAULT_ENABLED and the workspace has a
  provider_name (vault-onboarded), try the vault first and fall through to the
  existing Secrets-Manager path on null. Absent provider_name / flag off skips
  the vault entirely, so SM-only installs synth + behave unchanged.
- RegistryRow gains provider_name; parseRegistryRow now maps it.
- Tests: vault module (happy / consent-required→null / no-WAT / error→null) and
  resolver (vault-used-no-SM-read, vault-null→SM-fallback, no-provider_name→skip).

Agent-side reactions token + the CLI setup path that writes provider_name are
the next slices. No behavior change with the flag off.
Slice 3b of RFC #249 Phase 1 — the agent (reactions) token path.

- config.py _resolve_linear_token_via_vault: self-mints through boto3
  (get_workload_access_token_for_user_id → get_resource_oauth2_token,
  USER_FEDERATION) so it behaves identically on the AgentCore and ECS
  substrates — both authorize against the agent session role's IAM rather than
  the AgentCore-injected WorkloadAccessToken header. User id is
  linear-workspace-<orgId> (matches the Lambda resolver + registry-table
  convention). Returns '' — never raises — on consent-required / error, so
  resolve_linear_api_token falls through to the Secrets-Manager token.
- resolve_linear_api_token tries the vault first when LINEAR_VAULT_ENABLED and
  the task carries linear_provider_name + linear_workspace_id; SM-only installs
  and the flag-off path are untouched. Updated the stale 2.0a-parked docstring
  (the spike showed USER_FEDERATION no longer has the bug that parked it).
- Tests: mint-and-cache, consent-required→SM, vault-error→SM, no-provider→skip,
  flag-off→skip. Full agent quality gate green (1588 pass, 82.8% cov).

The CDK wiring that stamps linear_provider_name/linear_workspace_id into
channel_metadata + grants the agent session role is the next step. No behavior
change with the flag off.
…grant (#809)

Slice 3c of RFC #249 Phase 1 — makes the agent-side vault path actually fire.

- ResolvedLinearToken gains providerName; resolveLinearOauthToken returns it on
  both the vault and SM paths (carried through so the agent can attempt the
  vault even when the Lambda resolved via SM).
- linear-webhook-processor stamps linear_provider_name + linear_workspace_id
  into channel_metadata for the single-task path, and persists
  linear_provider_name into the orchestration release_context so released
  sub-issue children inherit it (workspace id already rides the existing
  credentials_ref stamp — no duplicate).
- orchestration-store/release thread linear_provider_name through the persisted
  ReleaseContext + release params.
- agent.ts: resolve enableLinearIdentityVault early; set LINEAR_VAULT_ENABLED +
  LINEAR_WORKLOAD_IDENTITY_NAME on the agent runtime env (both substrates) and
  grant the agent session role the token data-plane calls — only under the flag.
  Workload name is one shared const so env + construct can't drift.
- Tests: agent-stack gate (default synth omits vault + grant + env; flag-on gets
  the grant + env). Touched suites green (webhook-processor, orchestration
  store/release, agent stack). No behavior change with the flag off.
Slice 4 of RFC #249 Phase 1 - the CLI path that makes a workspace
vault-onboardable (writes provider_name, which is what activates the runtime
vault path built in slice 3).

- New cli/src/linear-vault.ts helpers:
  - upsertLinearCredentialProvider: create (idempotent, update on conflict) the
    CustomOauth2 provider from the workspace's Linear app client id/secret;
    returns the fixed vault callback URL to register on the Linear app (spike F6).
  - beginVaultConsent: drive the 3LO USER_FEDERATION round-trip - user-bound
    workload token then get-resource-oauth2-token with customParameters
    actor=app + prompt=consent (spike F1); returns the authorizationUrl plus a
    poll() that yields the minted token once consent completes server-side.
  - Deterministic linearVaultProviderName / linearVaultUserId matching the
    runtime resolvers.
- New 'bgagent linear vault-setup <slug>' subcommand: layers the vault onto an
  already-SM-onboarded workspace (reads client id/secret + workspace id from the
  existing registry row / SM secret), creates the provider, prints the callback
  URL to register, opens the consent browser, polls for the token, then records
  provider_name on the registry row. Localhost return URL (already on the
  workload allowlist); consent completes at the AgentCore callback, polled - no
  localhost listener needed.
- Adds @aws-sdk/client-bedrock-agentcore (data-plane) as a direct cli dep.
- Tests: provider create + idempotent conflict-update, consent
  needed-poll-token, already-consented shortcut, neither-token-nor-url error.
  Full CLI suite green (758). Additive - no existing command changed.
`bgagent linear vault-setup maguireb` reported an already-onboarded workspace as
"not onboarded". The filtered Scan passed `Limit: 1`, but DynamoDB applies
`Limit` to the items READ *before* the FilterExpression runs — so it read one
arbitrary row (demo-abca), filtered it out, and returned nothing. Live-caught on
a two-workspace registry; reproduced with the AWS CLI (Limit 1 => ScannedCount 1,
Count 0; no Limit => Count 1).

- Extract the lookup into an exported `findWorkspaceRowBySlug` (matching the
  file's existing exported-for-test idiom) with no `Limit`, and match the slug
  explicitly rather than trusting Items[0].
- Tests: finds a workspace that is NOT the first row scanned (the broken case),
  asserts no `Limit` is passed, and still returns undefined when genuinely absent.
- Verified against the live dev registry: maguireb + demo-abca both resolve, a
  bogus slug still does not.

NOTE (pre-existing, not changed here): `findReusableOauthAppCredentials` has the
same Limit-with-filter shape, and its test asserts `Limit: 1` — so a registry
whose first scanned row is inactive would wrongly report "no active workspace".
Left alone as out of scope for this issue.
…ready-exists (#809)

AgentCore reports a duplicate name as a ValidationException, not the
ConflictException the name suggests. Verified live against both APIs:
  CreateOauth2CredentialProvider -> "Credential provider with name: … already exists"
  CreateWorkloadIdentity         -> "WorkloadIdentity with name '…' already exists."
Both arrive as ValidationException, so the idempotent create-then-update paths
rethrew instead of updating.

Two real failures fixed:
- `bgagent linear vault-setup` aborted on a re-run ("… already exists") instead of
  updating the provider in place. Live-caught.
- The CDK custom-resource handler would have failed the NEXT deploy: CloudFormation
  sends an Update, the handler re-issues CreateWorkloadIdentity, and the unhandled
  duplicate error rolls the stack back. Caught by deliberately re-creating the
  existing workload identity rather than assuming the guard worked.

Both sites now share the same predicate shape (name === 'ValidationException' &&
/already exists/i), keeping ConflictException accepted in case the service tightens
this later, and still surfacing GENUINE validation errors (bad endpoint, bad return
URL) rather than silently treating them as "exists".

Tests: duplicate-ValidationException updates in place, and a genuine
ValidationException still fails — for both the CLI helper and the CDK handler.
)

Live-caught on dev: with the vault enabled and a provider_name on the workspace,
the webhook processor's vault call was denied —

  not authorized to perform: bedrock-agentcore:GetWorkloadAccessTokenForUserId
  on resource: …:workload-identity-directory/default

grantMintToken scoped both actions to the NAMED workload-identity ARN, but
GetWorkloadAccessTokenForUserId authorizes against the workload-identity
DIRECTORY. The unit test asserted the ARN we chose, not the one the service
requires, so only a live run could surface it.

- grantMintToken now grants the directory ARN (keeping the named identity
  alongside, in case the service tightens later); GetResourceOauth2Token is
  scoped to this account's token-vault oauth2 credential providers (names are
  created at onboarding time, so not known at synth); adds GetSecretValue on
  `bedrock-agentcore-identity!*`, which the vault reads through the caller when
  exchanging (ADR-016 P1 notes the same grant). Still no wildcard resource and no
  control-plane lifecycle actions.
- ECS substrate parity — the same class as the F-2 memory-grant bug. The ECS
  container has its OWN baseEnvironment and task role, so the AgentCore runtime
  env/grant never reached it: the ECS agent would silently stay on Secrets
  Manager even with the vault on. EcsAgentCluster now takes an optional
  linearIdentityVault and wires BOTH the env (LINEAR_VAULT_ENABLED /
  LINEAR_WORKLOAD_IDENTITY_NAME) and the task-role grant, mirroring the
  toolGateway/agentMemory props. The vault construct moved earlier in agent.ts so
  it exists before the ECS cluster; the runtime-role grant stays where `runtime`
  is defined.
- Tests: the construct test now asserts the SERVICE-REQUIRED resources
  (directory ARN, credential-provider ARN, identity secret) and still proves
  least-privilege; new ECS parity test with positive + negative proof, mirroring
  the F-2 guard.

Fail-safe behaviour confirmed live throughout: the denied vault call fell back to
Secrets Manager and the event degraded gracefully rather than crashing.
Second live AccessDenied after the workload-directory fix:

  not authorized to perform: bedrock-agentcore:GetResourceOauth2Token
  on resource: …:token-vault/default

GetResourceOauth2Token authorizes against the token VAULT itself, not only the
oauth2credentialprovider sub-path that was granted. Add the bare
token-vault/default ARN alongside the per-provider path (kept so the grant still
holds if the service moves to per-provider authorization). Test asserts both.
…#809)

With IAM fixed, the runtime vault call succeeded but reported "requires consent"
for a workspace whose grant WAS cached — every resolve silently degraded to the
Secrets-Manager fallback.

AgentCore keys a cached grant by the full token-request shape, customParameters
included. Isolated live against the same user + provider:

  with customParameters {actor: app, prompt: consent} -> accessToken returned
  without them                                       -> authorizationUrl ("needs consent")

So the runtime resolvers must send the IDENTICAL parameter set that
`bgagent linear vault-setup` used at consent time. Both resolvers now do:
- cdk/src/handlers/shared/linear-vault-token.ts: new exported
  LINEAR_VAULT_CUSTOM_PARAMS, passed on GetResourceOauth2Token.
- agent/src/config.py: mirrored _LINEAR_VAULT_CUSTOM_PARAMS (the two are kept in
  sync by comment, like the scopes list).

Tests assert the parameters on both sides, with the reason recorded so a future
"these look redundant on a read" cleanup does not silently reintroduce the
cache miss. Neither the Phase-0 spike nor unit tests could surface this — it only
appears when retrieving an already-consented grant.
The consent flow never completed. `vault-setup` opened the browser and polled
`GetResourceOauth2Token`, but AgentCore's post-consent bounce was unhandled:
nothing listened on the return URL (the browser 404'd) and the federation session
was never finalized, so the poll kept getting an `authorizationUrl` as if consent
had not happened and would have spun until timeout.

The real flow — already documented in this repo's own oauth-callback-server.ts
(lines 103-107, from the parked Phase-2.0a work) — is: Linear consent → AgentCore's
callback (AWS exchanges the code) → AgentCore redirects the browser to
`resourceOauth2ReturnUrl?session_id=…` → the caller captures that session_id and
calls CompleteResourceTokenAuth. This also CORRECTS spike finding F10, which had
called the return URL "cosmetic" — it is load-bearing. The spike reached that
wrong conclusion because it never completed a consent.

- New `finalizeVaultConsent()` wrapping CompleteResourceTokenAuth for the
  per-workspace user id; `beginVaultConsent` now also returns the `sessionUri`.
- vault-setup starts `awaitOauthCallback()` (the existing helper `linear setup`
  already uses) BEFORE opening the browser, requires the `agentcore` callback
  kind, finalizes, then fetches the token. Rejects a `direct-oauth` callback with
  a pointer to `linear setup`, and the timeout message no longer blames consent.
- Idempotent re-runs: probe the CACHED grant before forcing a fresh
  authorization, so re-running on a healthy workspace is a no-browser no-op
  instead of dragging the operator through consent to re-record a provider name.
- Tests: sessionUri is exposed, finalization targets the right user + session,
  a finalization failure propagates rather than leaving the caller polling, and
  the cached re-run makes no forced round trip.

Verified end-to-end against the live maguireb workspace: `bgagent linear
vault-setup maguireb` now completes with no browser and records provider_name.
The CompleteResourceTokenAuth call itself was live-proven earlier (it is what
minted the token that made the runtime vault path work). Full CLI suite green (766).
…809)

Slice 2 of RFC #249 Phase 1. The consent flow previously required a listener on
the CLI's own localhost, which only works when the browser and the CLI share a
machine — on a cloud desktop, SSH box, or container the redirect dead-ends and the
workspace cannot be onboarded at all.

- New LinearVaultConsentPage: private S3 bucket + CloudFront with Origin Access
  Control (same shape as ScreenshotBucket), serving one self-contained static page
  that reads `session_id` from the query string and shows it for the operator to
  paste back. Wired behind the existing `enableLinearIdentityVault` gate; its URL
  is registered on the workload identity's return-URL allowlist alongside the
  localhost loopback (both coexist — the CLI picks per call) and published as the
  `LinearVaultConsentUrl` output.
- CLI: `--hosted` selects that URL (failing closed with a clear message if the
  stack does not publish it), and `--session <sessionUri>` finishes a consent that
  already happened in the browser — no listener at all.

WHY THE PAGE IS STATIC. Finalizing needs CompleteResourceTokenAuth. Doing that on
the page would mean a public, unauthenticated endpoint that completes OAuth
sessions for whoever calls it. Instead the page only DISPLAYS the id and the CLI
finalizes with the operator's own AWS credentials, so the privileged step stays
with a principal we can authorize. (This supersedes an earlier note in the spike
doc that called the return URL "cosmetic" — it is load-bearing, but display-only.)

- The session id is untrusted query input reflected onto the page, so it is
  assigned via `textContent` only. A test asserts no markup-writing sink
  (innerHTML/outerHTML/document.write/insertAdjacentHTML) and no eval appears
  anywhere in the rendered page, that the page is fully self-contained (no
  external script/style/font/URL), and that it degrades to a clear message when
  opened without a session_id.
- Bootstrap least-privilege: map Custom::CDKBucketDeployment, and the
  synth-coverage guard now asserts EVERY CFN type the gated path adds is mapped
  and covered (not just the vault's own), so a future gated resource cannot slip
  in unmapped.
- Stack gate tests extended: default synth still omits the page + output; flag-on
  publishes LinearVaultConsentUrl and ships the deployment.
…arned gotchas (#809)

Marks P1 built + live-verified (flag-gated, SM fallback retained) and adds a
Phase-1 implementation-notes section. The notes are properties of AgentCore
Identity rather than of Linear, so they apply to the Jira/Slack/GitHub
ChannelCredentials in P7 — and none of them are reachable from a unit test:

- The data plane authorizes against surprising resources: the workload-identity
  DIRECTORY (not the named identity) and the token VAULT (not the credential
  provider sub-path). Both surfaced only as a live AccessDenied, because a unit
  test can only assert the ARN you chose. Confirms the GetSecretValue on
  bedrock-agentcore-identity!* that this ADR already anticipated.
- A cached grant is keyed by the whole token request INCLUDING customParameters,
  so resolving without the set used at consent time reports 'consent required'
  despite a valid grant and silently degrades to the fallback.
- Consent must be finalized with CompleteResourceTokenAuth using the session_id
  from the return-URL bounce; the return URL is load-bearing, not cosmetic.
- Revocation changes shape on the vault path (authorization URL, not
  invalid_grant), which is why detection needs #812.
- Two deliberate deviations from the P1 text: a provider per workspace (grant
  isolation) and the agent self-minting on both substrates.
- What Phase 1 does NOT deliver: per-user token attribution (workspace bot
  identity; P6 still open), and it does not stop a vendor revoking a grant.

Starlight mirror regenerated; docs build + link check green.
Synth failed with cdk-nag ERRORs (IAM4 managed policy, IAM5 wildcard S3 grants,
L1 runtime) on CDK's BucketDeployment handler. The suppressions were attached to
this construct, but that handler is a SINGLETON created at STACK scope, so
suppressing on `this` never reached it.

Resolve the singleton by id prefix (`Custom::CDKBucketDeployment*`) rather than
hardcoding CDK's version-dependent hash, which would silently stop matching on a
CDK bump and re-break synth with an error that points at framework code.

Verified live on backgroundagent-dev: synth passes nag, the page serves 200 over
CloudFront, the origin bucket is private (direct S3 GET 403, BPA all-true), the
workload identity allowlist carries BOTH return URLs, and the CLI resolves
LinearVaultConsentUrl for --hosted.
)

The page's delivery machinery (bucket, policy, auto-delete custom resource,
distribution, OAC, and CDK's BucketDeployment singleton with its AWS-CLI layer,
role and policy) is ~10 resources. Carried in the root stack that took
backgroundagent-dev from 486 to 496 against CloudFormation's hard 500-per-stack
limit — spending most of the remaining headroom on a landing page.

Nesting gives those resources their own budget and costs the parent a single
AWS::CloudFormation::Stack, the same trade AgentRegistryStack already makes for
the registry. Root synth is back to 487; the nested template holds 13.

- Bootstrap coverage follows the resources: the synth-coverage guard now walks the
  nested stack too, since CFN creates nested resources with the same execution
  role and an unmapped one fails deploy exactly like a root one.
- Gate test asserts the nesting (root must NOT carry Custom::CDKBucketDeployment)
  so a future inline regression is caught rather than silently eating headroom.

Verified live on backgroundagent-dev: the migration completed, the page serves 200
from the new distribution, and the workload identity's return-URL allowlist
reconciled to the new CloudFront URL by itself — which also confirms the
duplicate-name Update path added earlier works in production.
…809)

The existing assertions prove a dangerous sink is ABSENT. They cannot prove the
page WORKS: a typo in an element id or in the query-parameter name would sail past
every grep and only surface in a browser, on the one flow an operator runs once.

Run the page's inline script under vm against a minimal DOM shim, and assert
behaviour:
- session_id is extracted and rendered, along with the command to run
- a HOSTILE id ('"><img src=x onerror=alert(1)>') survives verbatim as text and
  touches NO markup sink — the shim records any innerHTML/outerHTML write, so this
  demonstrates the XSS property instead of implying it
- with no session_id the page explains itself and removes the empty slots

Also live-checked against the deployed distribution: the page serves 200 with a
session_id query string attached and the bytes are identical to the no-query
response, which is correct — the id is read client-side from window.location, so
the HTML never varies and the cache key legitimately excludes the query string.
The previous commit shipped with eslint failing (@typescript-eslint/no-require-imports
on `require('vm')`). CI's mutation guard would have rejected it — I committed
without re-reading the lint result. Import vm at the top of the file instead.
…flow (#809)

`bgagent linear app-template` printed only the localhost callback, so an operator
following it and then running `vault-setup` hit Linear's 'Invalid redirect_uri
parameter for the application' with nothing in the output pointing at the cause.
Live-caught during onboarding.

The vault flow needs a SECOND callback on the Linear app — the provider callback,
whose id is minted when the credential provider is created, so it cannot be printed
before that exists. The template now says exactly that where the URLs are listed,
and the gotcha list names the misleading error it produces. `--vault-callback-url`
renders a complete template once the provider exists (additive: localhost stays, or
`bgagent linear setup` breaks).

Note the three URLs are sourced differently: the localhost callback is a CLI
constant, the hosted consent page URL IS a stack output (LinearVaultConsentUrl,
read by --hosted), and the provider callback is neither — it is runtime output from
provider creation, which is why no template can carry it.
…iling tasks (#812)

A revoked Linear grant was detected perfectly and then acted on not at all: the
operator found out because a user's task silently did nothing, and every later
event re-detected the same dead grant with no dedup.

`markWorkspaceRevoked` already existed and was already careful (conditioned on
`installed_at`, so a verdict can never revoke the SUCCESSOR installation) — but it
was permanently inert, because every token-resolving role held read-only registry
access, the conditional write failed AccessDenied, and the failure was deliberately
swallowed so a diagnosis could not break token resolution. The feature read as
implemented while doing nothing.

- Grant the webhook processor registry WRITE and pass the recorder through, so the
  latch actually lands. It is the right holder: it is the path a revoked workspace
  hits on every event.
- `markWorkspaceRevoked` now returns WHETHER it latched. That boolean is the dedup
  key — only the caller whose conditional write applied announces, so one
  revocation produces one alert rather than one per inbound event.
- New `linear-revocation-alert.ts` publishes that one announcement to the existing
  operational SNS topic, naming the workspace and the exact recovery command.
  SNS is deliberately the channel: the dead credential is Linear's own, so a Linear
  comment cannot report it — that is precisely the path that no longer works.
- Vault parity: a revoked grant surfaces on the AgentCore path as "consent
  required" rather than `invalid_grant`, so a detector keyed on the error shape
  would go quiet exactly when a workspace moved onto the vault. The vault resolver
  now returns a TYPED result instead of collapsing "needs consent" and "throttled"
  into one null (the silent-success-masking shape the repo lints against), and the
  resolver reports it — but only when NO path can produce a token, so a
  vault-dead/SM-alive workspace is not marked revoked and taken offline.
- Announcing stays best-effort and never throws; the latch has already landed, so
  `platform doctor` can explain the state even if notification fails.

Two things the work surfaced:
- The CMK-encrypted topic needs `kms:GenerateDataKey*`/`Decrypt` as well as
  `sns:Publish`. `grantPublish` does not grant the key, and since announcing is
  best-effort the failure would have been swallowed — a new dormant feature in
  place of the one being fixed.
- The added grants pushed the processor role past IAM's inline-policy size limit,
  so CDK now spills an `AWS::IAM::ManagedPolicy` overflow policy. Mapped it in the
  bootstrap action map; caught by tightening the synth-coverage guard to reject ANY
  unmapped type on the gated path, root or nested.

Full CDK suite green (4189 tests, 205 suites).
…t invalid_grant (#812)

The revocation branch could never run. It tested `error === 'invalid_grant'` — what
RFC 6749 specifies — and Linear does not send that. Linear answers a dead refresh
token with HTTP 400 and:

  { "error": "invalid_request", "error_description": "Refresh token revoked" }
  { "error": "invalid_request", "error_description": "Invalid refresh token" }

so every real revocation was filed as a generic `failure`. The permanent-rejection
path never executed, the registry was never marked, and no operator was ever
notified: detection that existed and could not fire. This is a SECOND, independent
reason the feature was dormant — fixing the missing registry-write grant alone would
not have made it work.

Found by live testing, not review: unit tests mocked the token endpoint with the
error code the test author assumed. Confirmed two ways — against the token-lineage
logs from the #807 investigation (maguireb's real death was `invalid_request` +
"Refresh token revoked"), and by deliberately driving a refresh with a bogus token
through the deployed Lambda.

`isRefreshTokenRejection` now accepts `invalid_grant` (in case Linear aligns with the
RFC) OR a 400 whose description names the refresh token. `invalid_request` alone is
deliberately NOT enough — it is also what a malformed request, i.e. our own bug,
returns, and marking a workspace revoked for that would take a working workspace
offline. Tests cover both observed payloads, the RFC form, a generic
`invalid_request`, and transient 5xx/429.
…hree I wired (#812)

Live test caught this: the permanent-rejection branch ran, logged
"workspace requires re-onboarding" — and still did not latch or announce, because
the recorder was threaded through call sites and the discovering caller was not one
of them. Seven modules resolve Linear tokens inside the webhook processor; I had
wired three. The one that found it was `linear-feedback`, which is a likely
discoverer precisely because posting a reply is often the first thing needing a
token.

Threading it per-caller is the wrong shape: every future caller has to remember,
and forgetting is silent. The resolver now builds the recorder itself from the
DynamoDB client and table name it already holds, so every path — present and
future — is covered. A caller-supplied recorder still wins, which is what tests use.

Gated on `LINEAR_REVOCATION_RECORDING`, set ONLY on the webhook processor role that
holds registry write. Everywhere else the conditional write would fail AccessDenied
and be swallowed — the inert-but-looks-implemented state this issue exists to
remove, and the reason the resolver deliberately never defaulted this before.

Also breaks a would-be import cycle: the alert module no longer imports the
resolver. The resolver owns the latch and calls a pure SNS `announceRevocation`,
which declares its own input type. Tests follow that split — dedup is asserted on
the latch in the resolver suite, message content on the announcement.

Full CDK suite green (4192 tests, 205 suites).
…-back (#809)

The hosted consent page only fixed the SECOND onboarding step. `vault-setup` layers
onto an already-onboarded workspace, and the prerequisite `bgagent linear setup`
still required a localhost listener — so on a cloud desktop, SSH box, or container
you were blocked at step 1 and never reached the part that had been fixed. Half a
feature: the localhost dependency was moved, not removed.

- The consent page now serves BOTH flows. The vault 3LO bounce arrives with
  `session_id`; the direct OAuth redirect arrives with `code` (+ `state`). One
  registered URL therefore REPLACES the localhost callback instead of merely
  supplementing it.
- `bgagent linear setup <slug> --hosted` sends the redirect to that page and stops,
  printing what to paste back. `--code <code>` finishes the exchange.
- New `linear-pending-consent.ts` carries the PKCE verifier + `state` between those
  two invocations. The code alone cannot complete a PKCE exchange, and `state` still
  has to be checked for CSRF. The verifier is a ONE-TIME SECRET, so the entry is
  0600, consumed on read (including on refusal), rejected past a 15-minute TTL, and
  keyed per workspace so two onboardings cannot clobber each other.
- `app-template` can now list the hosted page (`--hosted-consent-url`) and, when it
  cannot, says plainly that localhost will not work on a cloud desktop and names the
  flag that does — the instructions have to match reality or they send an operator
  into the "Invalid redirect_uri" dead-end this issue already produced once.

Localhost remains the default for same-machine use; nothing about that path changed.

Tests: page behaviour executed under vm for the code branch (including a hostile
code rendered as text, and session_id winning when both are present) and the full
pending-consent contract. CDK 4195 / CLI 780 green.
The consent page is served from a GENERATED CloudFront domain, and with --hosted
that URL is registered as a Callback URL on the Linear app. It changed twice during
development — the domain is stable only while the distribution is, and the
distribution is replaced whenever the consent page is recreated (flag toggled off
and on, nested stack recreated). A stale registration presents as the same
'Invalid redirect_uri' dead-end that already cost an onboarding attempt once, with
nothing pointing at the cause.

Both --hosted paths now print the URL with that caveat and the remedy: Linear
accepts several callback URLs, so adding the new one is safe and the stale entry is
harmless. Surfacing it beats an operator rediscovering it.

A stable custom domain would remove the caveat entirely but needs a hosted zone and
certificate — out of scope here, worth noting as the real fix.
…nding them (#809)

`app-template` is the FIRST command an operator runs, and it required
`--hosted-consent-url` — a CloudFormation output — plus a second callback URL
that AgentCore mints at provider-create time. Both were things the operator had
to go and find before the command that explains onboarding would tell them
anything useful.

It now resolves both itself:
  - the hosted consent page from the stack's LinearVaultConsentUrl output
  - the vault provider callback via GetOauth2CredentialProvider, given --slug

Every lookup is best-effort. An unconfigured CLI, absent credentials, an
undeployed stack, a flag-off deploy, and a not-yet-created provider all degrade
to a NOT FOUND line that says what is missing and how to get it, so the command
still renders offline. The flags remain as overrides, and --offline skips the
lookups entirely.

Each URL is now labelled with the command that redirects to it. Registering the
wrong subset presents as an opaque Linear "Invalid redirect_uri", and an
unlabelled list gave no way to tell which entry a failing command wanted — the
exact dead-end that cost one onboarding attempt.
`Application name` was hardcoded to `bgagent`, so anyone running ABCA under
their own branding had to notice the template was wrong and edit around it.
`--app-name` now sets it, and the GitHub username Linear requires for actor=app
is DERIVED from that name — one choice instead of two identifiers to keep
consistent. `--bot-name` still overrides.

The name is sanitized to something GitHub accepts (lowercase, single inner
hyphens, 39 chars) and falls back to the default when a name has no usable
characters at all, because an empty username field fails on Linear's side with
the misleading "Invalid redirect_uri" error rather than naming the real problem.
That fallback warns on stderr, since the template claims the username follows
the app name and would otherwise be quietly lying.

The template now also states that renaming does NOT change the trigger phrase.
`parseCommentTrigger` matches a fixed `@bgagent` token that is independent of
the app's name (verified against real workspace comments: app-authored comments
carry the display handle derived from the application name, while the trigger
token is plain text in the body). Without saying so, renaming yields an agent
that looks right and answers nothing.

Annotations are pinned to a column so operator-chosen values of differing
lengths do not leave a ragged edge.
…ired (#809)

The app template and setup guide both asserted that a "GitHub username" field
and the app's Webhooks toggle are REQUIRED for actor=app, and blamed a blank
username for Linear's "Invalid redirect_uri parameter for the application"
error. Neither claim holds up:

  - Linear's OAuth documentation describes no GitHub-username field, and its
    `Application` GraphQL type exposes only clientId, description, developer,
    developerUrl, id, imageUrl and name.
  - ABCA's events arrive through the WORKSPACE webhook the operator creates
    separately (`bgagent linear webhook-info`), not the app's own toggle. The
    template already conceded the webhook URL is "unused by the OAuth dance"
    while still calling it required.

The cost of the wrong attribution is real: it sends operators to fix a field
that may not exist while the actual cause — a callback URL that is not
registered on the app — stays broken. That cause is the one seen in practice,
including for the AgentCore vault, whose provider callback is a SECOND URL the
app must list.

So the error's guidance now points at the registered-URL list (all URLs present,
one line each, no wildcards), the username line is marked "only if your form
shows this field", and the webhook note explains what actually delivers events.
Also records that actor=app and the `admin` scope are mutually exclusive, which
Linear documents and the template did not mention.

Tests now assert the corrected facts and that the two false REQUIRED claims stay
gone, so they cannot creep back.
)

Checked the template field-by-field against the real app-creation form. It was
both longer than anyone would read and wrong in ways that cost onboarding
attempts:

  - "GitHub username" does not exist. The form is icon, application name,
    developer name, developer URL, description, redirect URIs. Removed the field,
    the --bot-name flag, and the name-to-username derivation behind it.
  - The field is "Redirect URIs" ("All OAuth redirect URIs, separated with
    newlines"), not "Callback URLs" — an operator scanning for our label would
    not find it.
  - The webhook URL was printed as https://example.com/placeholder alongside "you
    do NOT need to subscribe to any events", which is true of the OAuth dance
    alone and leaves an app that can never deliver an event. It now prints the
    real endpoint, derived from the configured API URL (no AWS call), and asks
    for Issues + Comments.
  - Webhooks CAN be configured on the app; the previous commit's claim that ABCA
    does not use the app-level toggle was wrong. Either the app's own webhook or
    a workspace webhook works, since the receiver verifies whichever signing
    secret it is given.

Adds the trap that actually produced "Invalid redirect_uri" here: redirect URIs
are matched as exact strings, so a missing trailing slash fails — the hosted
consent URL ends in one. Also flags that ticking Issues without Comments
silently disables the comment trigger while labels keep working, which is the
kind of half-broken state nobody thinks to check.

Two `setup` paths still said "Subscribe to: Issues", contradicting webhook-info.
Both now name Comments and why.

Net: 55 lines to 45, every URL real, every claim checked.
Reverts the previous commit's claim that the OAuth app's own webhook toggle is a
valid place to configure ABCA. Checked against the running deployment: the live
subscription is a WORKSPACE webhook (Issue + Comment, enabled) and the app's
webhook is off. That is the configuration in use, and the only one verified.

Worse, the previous commit printed ABCA's endpoint under the app's "Webhook URL"
field. Following that alongside the documented workspace webhook yields two
subscriptions to one endpoint: every event delivered twice, under two different
signing secrets, while ABCA stores one per workspace — so the duplicate both
doubles the work and fails to verify. Now stated as a trap, since pasting the URL
in both places looks harmless.

The endpoint is still resolved and printed, but labelled as the workspace webhook
to create, so nobody has to go and find it.

Also trimmed the trap list back under the length budget the rewrite set. Length
is a real defect here: the list had grown to the point an operator stopped
reading and left the webhook URL blank because an earlier version called it a
placeholder.
…ace one (#809)

One webhook, set up while creating the app, instead of an app plus a separate
workspace subscription in a later step. Nothing in the receiver cares which
surface delivered the event: it routes on the payload's `organizationId` and
verifies the HMAC against whichever signing secret was stored for that
workspace, so the app's own secret works unchanged.

This also removes a whole failure class from onboarding — the per-workspace
signing secret that a second `linear setup` run could overwrite, leaving the
first workspace returning 401.

The template prints the real endpoint in the app's Webhook URL field, asks for
Issues + Comments, and says to keep the signing secret for `setup`. The guide
now treats the app-level webhook as the path and a workspace webhook as the
alternative — explicitly an either/or, since configuring both delivers every
event twice under two secrets with only one able to verify.

Multi-workspace note: an app webhook has one signing secret shared by every
workspace the app is installed on, where workspace webhooks get one each. Both
work, because the secret is stored per workspace either way.
…ide (#809)

Two spots still told operators to "rely on the Issues + Comments webhook events
(step 4)" and to "keep only the workspace webhook", which contradicts the
app-level webhook the template now configures. Both now name the App events
checkboxes as the thing to untick and say to keep the webhook itself enabled with
the Issues + Comments data-change events.
@isadeks

isadeks commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 6288d1c7 — three commits

Thank you for withdrawing B1 the way you did. Bundling each entry with CDK's own esbuild config and counting the emitted require is a better standard than the static import-chain reading I would have applied, and it found the thing that settles it: main already ships require("@aws-sdk/client-bedrock-agentcore") from fanout-task-events and github-webhook-processor under the identical externalization, so the module demonstrably resolves in nodejs24.x.

The two-line remnant was real and is fixed: makeClient now sits inside the try, so an SDK surprise degrades to { kind: 'unavailable' } and the Secrets-Manager fallback rather than escaping past the classification into a Step 1b that has no guard.

Every finding below I verified against the code before changing anything. None was a false positive — I looked, and the one thing worth reporting in that direction is that the four cache-key copies already agree, so that work is a guard rather than a repair, and the commit says so.

The four correctness MAJORs

setup stripping the vault binding. Confirmed: alreadyOnVault is used at :954 ("Only a FIRST onboarding falls back") but not at :928, so a missing stack output or a --code resume rewrote the row whole and dropped provider_name/vault_user_id. It now fails fast, naming which of the two causes applies. Your point about the fabricated diagnosis was right too — nothing probed regional availability, only this stack's outputs — so the message now says that instead of sending the operator to the AWS region table.

The CLI collapsing "vault unreachable" into "grant revoked". Fixed as you suggested: mintLinearTokenFromVault returns a tri-state mirroring the cdk-side VaultTokenResult, unavailable maps to unknown, and a latch is no longer overturned by a probe that merely failed. The two callers that genuinely only need a token now say so explicitly at the call site. The nosemgrep rationale claiming the distinction changes no caller's action was falsified by the caller this PR added — it is gone, not reworded.

The un-latch not env-gated like the latch. Gated on recordRevocation, which is present exactly where the registry write is, so the same permission governs both directions and no new env read is introduced. A test pins that a read-only role still serves the token and attempts no write.

The quadruplicated cache key. Now a linear_vault block in contracts/constants.json, enforced the two ways this repo already enforces such things: check:constants-sync forbids config.py re-declaring the literals, and parity tests assert each TypeScript copy equals the contract.

Two things surfaced while wiring that, both of which would have left the guard decorative. agent/src/config.py was not in the gate's PYTHON_CONSUMERS, so the new patterns matched nothing — found by re-declaring a literal and watching the gate stay silent. And the gate's own fixture enumerates the files it copies, so it needed config.py too, or the checker read a path the fixture never created.

Tests and docs

The mislabelled test. You were exactly right, including the consequence: the harness never captured ExpressionAttributeValues, so the reason ternary was unverified and collapsing it to a constant kept the suite green. The harness now captures the values and can drive the vault source, both arms assert :reason, and I confirmed the collapse you described now fails.

Vault + lambda-microvm. AgentStack refuses the combination by name at synth instead of leaving it to the resource counter, whose per-type census never mentions either flag. Documented in the setup guide and ADR-016's substrate notes rather than living only in a test title. The test that pinned the 500-limit symptom now pins the explicit refusal.

Nits

All fixed except two, noted below. Four comments had stopped being true, three of them describing mechanisms this PR changed — the alert header still claimed the latch is the dedup key, agent.ts still called the workload name fixed, linear-auth-health still claimed it never consumes a refresh token, and the error-level justification cited an announcement that has not happened on the no-topic path. Also fixed: the raw 'vault_consent_required' literal, a typed LinearRevocationReason, RegistryRow.revoked_at, the .catch that turned a DescribeStacks AccessDenied into a fallback name no current stack uses, both --hosted references, and the guard-location pointer.

Your proportionality point landed, and I applied it in both directions: the LINEAR_REVOCATION_RECORDING block went from 19 lines to 9 and vaultMetadata's docstring from 18 to 9, keeping every decision — including why granting the reconciler the write is refused — and dropping the archaeology. Where a fact came from a live run rather than reasoning that is still stated, since it is the reason not to "simplify" the code back.

The vaultMetadata structural guard no longer requires adjacency; it scans to the end of the enclosing literal, so a key reorder cannot fail it while a new unspread builder still does. Fixtures use AWS's documentation host now, and both CodeQL alerts are dismissed with reasons.

Deferred, deliberately: the parseOauthSecret mode-keyed overloads. It is a genuine improvement and there is no live defect behind it — the only 'without-grant' caller is webhook verification — so I would rather not reshape a parse path this PR already changed twice. And the commands/linear.ts split, which we agree is a follow-up.

Not fixed, and I would rather you decide: your adjacent finding about cli/src/linear-oauth.ts printing the raw token body was fixed here in 2ced095e, but cli/src/jira-oauth.ts:327 has the identical pattern. That file is not in this PR's diff, so I left it — say the word and it gets its own PR.

mise run build green at this HEAD: CDK 4454, CLI 903, agent 1782.

An audit of the comments this branch adds turned up several that assert the
opposite of what the code now does, plus one rendering defect the source hides.

Factually wrong, now fixed:

- The `markWorkspaceRevoked` call site claimed the revoked-marker is "OPT-IN, not
  defaulted" and that "no stack grants it write", eleven lines under the line that
  defaults it — and the write grant plus `LINEAR_REVOCATION_RECORDING` now ship in
  `linear-integration.ts`. The block also directly contradicted the two lines
  appended beneath it.
- Step 1b claimed "the vault NEVER blocks token resolution". A row latched on the
  vault inference returns after the re-probe, so Secrets Manager is never consulted.
- `resolveViaVault` carried two docblocks; the dead first one described a
  `string | null` return the type no longer has.
- The vault-token header and `workspaceUserId` both presented
  `linear-workspace-<orgId>` as the live subject. It is the pre-#809 fallback —
  fresh installs bind the slug-derived id recorded at consent time. That function
  is now named `legacyWorkspaceUserId`, so the one fact a reader needs is in the
  name instead of a paragraph above it; its test pinned the same wrong framing
  ("matches registry-table convention") and now says what the pin is actually for.
- Two docblocks sat on the wrong declaration: the `resolveLinearOauthToken`
  contract on `ResolvedLinearToken`, and the `VAULT_CONSENT_REVOCATION_REASON`
  description on the type above it.
- The locally-declared `AnnounceableRevocation` cited an import cycle. `import type`
  is erased and no `import/no-cycle` rule is configured; the real reason is that
  the alert module depends on nothing of the resolver's.

Stale: the cache-key literals still said "keep in sync with" their three peers.
That is now enforced by `contracts/constants.json` and the parity test, so they
point at the contract instead. A cross-reference to `workspaceUserId` "in
config.py" named a symbol that never existed there — the agent inlines the form.

De-duplicated: the announce-before-latch rationale appeared five times and the
`LINEAR_REVOCATION_RECORDING` rationale three; each is now stated once where the
mechanism lives, with pointers elsewhere. Dropped an intra-branch commit SHA and
the bug-history retellings that stop meaning anything after squash-merge. Every
comment recording a non-inferable fact is kept verbatim — AgentCore's
`customParameters` cache key, Linear's non-RFC error body, the `installed_at`
conditioning, the nosemgrep justifications.

The resolver drops from 40% comment lines to 38%, against a 39% house norm in
`cdk/src`; no unique fact was removed.

Fixed while rendering the copy: the operator alert printed the same
`bgagent linear setup` command twice on the vault path, and a
`.filter(line => line !== '')` stripped every intentional blank line, collapsing
the email into one block. Both were invisible in source and green under
`toContain`; the new tests pin the command count and the paragraph breaks, and
both were mutation-proven to fail against the old rendering.

Also strengthened the refresh test, which asserted PutSecretValue was *called*
but not what it wrote — persisting the pre-rotation bundle left it green, which
is the failure mode that makes a grant die on its first refresh.
…s missed

The first pass covered 12 files (1,595 of 2,685 added comment lines). This one
covers the remaining 34, and re-sweeps all 46 for the cross-cutting categories.

Wrong:

- `linear-identity-provisioning` credited "the return URL is mandatory" to spike
  finding F8. That is F7; F8 is "a hosted https return URL works identically".
  Both now cited, along with F9 and F11.
- `linear-vault-consent-page` pinned "The root `AgentStack` was at 486" — an
  undated reading of a number that moves every PR, so it is unfalsifiable prose
  that will eventually read as wrong. States the constraint instead.
- Its two onboarding bullets both named `bgagent linear setup`, making the
  distinction they draw invisible. They are two redirect legs of one command.

Stale — `vault-setup` and `--hosted` were removed when onboarding was consolidated,
but five references still used them as current:

- `agent.test.ts` claimed the published output is what lets `vault-setup --hosted`
  find the consent page. Neither the command nor the flag exists (`--hosted-consent-url`
  does).
- `cli/src/linear-vault.ts` twice cited "a second `vault-setup` run".
- `linear-pending-consent.test.ts` used `setup --hosted` in its file header and in a
  test name.

The negative assertions that check `vault-setup` does NOT appear in operator output
are correct and were left alone.

Duplication, and the one place it was worth fixing structurally: five parallel
builder signatures in `linear-webhook-processor.ts` each carried a byte-identical
six-line inline type for `resolved` under a byte-identical four-line comment — 50
lines restating one fact five times. Replaced by a named `BuilderResolvedToken`,
so the invariant the comment warned about (never narrow this per site, the callers
pass the whole resolver result) is now enforced by the type instead of by prose.

Narration: dropped "this PR" from `check-constants-sync.test.ts` and
`linear-oauth-resolver.test.ts`, and the fourth restatement of the recorded-vs-derived
subject rationale in `config.py` is now a pointer. The `fb1e007b` reference in
`ecs-agent-cluster.test.ts` is kept — that commit is on main, so it stays resolvable.

Checked and deliberately left: `config.py`'s "the vault never blocks a task" is true
there (it reads no registry row and has no latch, unlike the Lambda resolver, where
the same sentence was wrong and was corrected in the previous commit).
…-revocation-alerts

# Conflicts:
#	cdk/test/stacks/agent.test.ts
…-pr' into feat/812-credential-revocation-alerts

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - consider a followup to address comments

@isadeks
isadeks added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 12c9b63 Sep 4, 2026
8 checks passed
@isadeks
isadeks deleted the feat/809-linear-identity-vault-pr branch September 4, 2026 16:42
scottschreckengaust added a commit that referenced this pull request Sep 4, 2026
…base

Rebasing onto main (12c9b63) surfaced three collisions with work that
merged after this branch was authored. None were textual conflicts — git
merged the affected files cleanly and produced a build that failed.

1. `allowTestInvoke: false` on the DELETE integration.
   #854 stripped the API Gateway console test-invoke Lambda permissions to
   reclaim CloudFormation resources under the 500-resource ceiling, and
   added a guard asserting none are emitted. This route predates that
   convention, so it re-introduced one and failed the guard 6x (once per
   compute_type x enableToolGateway variant). The two sibling routes in
   this same construct already pass the option; now all three agree.

2. Attributed-Lambda count 46 -> 47.
   The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main
   independently reached 46, so `git rebase` dropped the commit as "patch
   contents already upstream" — identical text, different reason. The
   textual change survived; the intent did not. With this branch's extra
   Lambda the correct value is 47.

3. Solution user agent on the new handler's SDK clients.
   linear-remove-workspace.ts constructed `new DynamoDBClient({})` and
   `new SecretsManagerClient({})` directly, which drops the solution user
   agent (#319). Now built through makeDocClient()/makeClient(), matching
   linear-link.ts and linear-webhook.ts.

Also resolved the LINEAR_SETUP_GUIDE.md conflict from #831: kept its new
"Vault-managed workspaces" section and dropped the duplicated webhook/
uninstall sentence, which this branch had already relocated into the
parent "Removing a workspace" section. Starlight mirror regenerated.

Refs #306
isadeks added a commit that referenced this pull request Sep 8, 2026
Conflicts with #831 (Linear OAuth Token Vault), which touched the same three files.
All three were additive on both sides; resolved as unions.

- `cdk/src/constructs/ecs-agent-cluster.ts` — imports. This branch's model constants
  plus main's `LinearIdentityVault`.

- `cdk/src/stacks/agent.ts` — the lambda-microvm `platform_config` block. Both sides
  add fields, so both are kept, but NOT verbatim: main's side calls
  `haikuInferenceProfileId(bedrockGeoRegion)`, and that helper no longer exists here —
  it was removed when the two duplicate haiku-id paths were collapsed into one. Taking
  main's line as written would not compile. Resolved to
  `inferenceProfileId(bedrockGeoRegion, PLATFORM_DEFAULT_AUX_MODEL_ID)`, which is the
  same value through the surviving helper, and the vault fields are kept unchanged.

- `cli/src/platform-doctor.ts` — three hunks: the `linear-auth-health` import, and the
  `Promise.all` destructuring plus its call list. The last two must stay positionally
  aligned, so both were extended in the same order; verified all ten names now line up
  with their outputs.

Also fixed a `{@link haikuInferenceProfileId}` in `bedrock-models.ts` left dangling by
that same helper removal — the merge is what surfaced it, since main's live call site
was the only other reference.

`yarn.lock` moves with main (+42, `@aws-sdk/client-sns` for #831's revocation alerts).

Checked for semantic drift rather than trusting a clean textual merge: the restored
"creates exactly 21 DynamoDB tables" guard is byte-identical to main's at the same line,
so #831 added no table and the merge did not take a stale side of it.

Suites after merge: 4499 CDK, 948 CLI, 1782 agent — `mise run build` clean.
@scottschreckengaust scottschreckengaust added the v1 Version 1 label Sep 14, 2026
ClintEastman02 pushed a commit to ClintEastman02/sample-autonomous-cloud-coding-agents that referenced this pull request Sep 16, 2026
…ed resolver (aws-samples#306) (aws-samples#681)

* feat(cli): bgagent linear remove-workspace + DELETE route + fail-closed resolver (aws-samples#306)

Add a `bgagent linear remove-workspace <slug>` command that deregisters a
Linear workspace, replacing the manual DDB + Secrets Manager surgery that
removal previously required.

CLI (cli/src/commands/linear.ts): new subcommand mirroring add-workspace /
update-webhook-secret UX — slug validation + a "type the slug to confirm"
prompt (skipped by --yes). Delegates all writes to the backend via a new
DELETE call so DDB/Secrets Manager grants stay on the API role, not on every
CLI user (same pattern as `link`). Flags: --purge, --keep-mappings, --yes.

Backend: new flat handler cdk/src/handlers/linear-remove-workspace.ts behind
DELETE /v1/linear/workspaces/{slug} (route + Lambda wired in
cdk/src/constructs/linear-integration.ts). Cognito-authenticated, admin-only
(caller must match the recorded installed_by_platform_user_id). Default is a
SOFT removal: flip the registry row to status=revoked (audit trail preserved)
and delete the bgagent-linear-oauth-<slug> secret; --purge deletes the row
outright. Secret deletion is idempotent (ResourceNotFoundException swallowed,
other SM errors rethrown). Optional project-mapping cleanup keyed on
linear_workspace_id.

Fail-closed resolver: the OAuth resolver already rejects any non-active
registry status (cdk/src/handlers/shared/linear-oauth-resolver.ts) — so a
revoked workspace can no longer resolve a token or route webhooks the instant
this returns. Added an adversarial test proving a revoked slug is rejected
WITHOUT ever reading the secret, plus an active-control case.

Docs: rewrote the "Removing a workspace" section of the Linear setup guide
(Starlight mirror regenerated) to lead with the command and keep the manual
DDB steps as a fallback.

Security: no secrets logged (slug/workspace_id/booleans only); reuses existing
AWS SDK clients; no new dependencies. SAST clean on new files.

Closes aws-samples#306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aws-samples#306): loud+recoverable partial teardown, phase logging, coverage gaps

Review follow-up (self-review agents on PR aws-samples#681):

- Secret-delete failure is no longer masked: a non-idempotent SM error
  (e.g. AccessDenied) after the row is revoked now returns a distinct
  500 SECRET_DELETE_FAILED and writes a durable secret_deletion_failed /
  orphaned_oauth_secret_arn marker on the registry row, so the leaked
  credential is discoverable (a naive retry 404s at the active-scan and
  would never re-attempt the delete). ResourceNotFoundException stays
  idempotent (200).
- Top-level catch + mapping-cleanup loop now log the failing phase +
  workspace id / per-page progress so on-call can locate an orphaned
  secret or half-cleaned mapping table from the request id.
- Tests: SECRET_DELETE_FAILED path + marker write; FilterExpression
  pins the status='active' fail-closed filter to the handler (not the
  mock); paginated mapping cleanup across LastEvaluatedKey; missing
  oauth_secret_arn skip; CLI confirmation-prompt abort/proceed;
  secret_deleted:false CLI output; api-client query-string mapping
  (purge / keep_mappings snake_case).

Relates to aws-samples#306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(aws-samples#306): clarify remove-workspace Lambda timeout rationale

Comment-only: the 30s timeout is higher than the 3s default the
link/webhook handlers use, not "higher than the other request handlers"
(the webhook processor also uses 30s). Nit from PR aws-samples#681 self-review.

Relates to aws-samples#306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aws-samples#306): paginate registry scan (fix 404 on live workspaces) + purge revoke-first + drop dead mapping cleanup

B1 (blocking): the registry lookup used `Limit: 1` on a FILTERED Scan.
DynamoDB applies FilterExpression after evaluating `Limit` items, so a
single-row limit examined one arbitrary row, filtered it out, and 404'd a
live workspace whenever the registry held >1 row (the normal shared-stack
state; guaranteed after the first soft-revoke). Drop `Limit` and paginate to
completion, matching jira-webhook-processor.ts / shared/linear-issue-lookup.ts;
document the small-table assumption. Fix the test double so the registry-scan
router honors Limit/ExclusiveStartKey and applies the filter to the examined
slice (this is why B1 was invisible), and add a two-active-row regression with
the target on the second page (fails before, passes after) plus a
follow-LastEvaluatedKey assertion.

B2 (blocking): mapping cleanup was a provable no-op reported as success —
LinearProjectMappingTable rows carry no workspace id (onboard-project writes
none), so the `linear_workspace_id` filter matched zero rows always while the
CLI printed "✓ 0 project mapping(s) removed". Take the reviewer's cheapest
honest fix: remove the mapping-cleanup path entirely — deleteWorkspaceProjectMappings
+ its call, the `--keep-mappings` flag, the projectMappingTable.grantReadWriteData
grant, the LINEAR_PROJECT_MAPPING_TABLE_NAME env, and the 30s timeout bump
(reverted to 10s matching siblings — N3). CLI output + LINEAR_SETUP_GUIDE no
longer claim mapping cleanup; mappings are removed by project id. Schema
follow-up (record linear_workspace_id at onboard time) to be filed separately.

B3 (blocking): on `--purge`, markSecretDeletionFailed early-returned, so a
failed DeleteSecret after the row was deleted leaked the OAuth secret with no
durable record. Reorder to revoke(Update)→DeleteSecret→delete-row(purge only,
after secret confirmed gone), so the marker always lands and fail-closed holds.
Drop the `purged` special-case; correct the two backwards comments (:249-251,
:283-285). Add a --purge marker regression + an ordering assertion.

N2: pin the previously-vacuous construct tests — resolve RemoveWorkspaceFn via
its unique DeleteSecret role grant, pin the DELETE method to the {slug}
resource, and assert the DeleteSecret grant is bound to that role AND scoped to
bgagent-linear-oauth-*.

Closes aws-samples#306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* test(aws-samples#306): de-tautologize role-binding assertion + fix pagination/test comments + runbook markers (aws-samples#306)

Addresses the 4 non-blocking approval nits from @isadeks:

1. Role-binding assertion (cdk/test/constructs/linear-integration.test.ts):
   findRemoveWorkspaceFn() now derives the remove-workspace role INDEPENDENTLY
   from the FUNCTION resource (its registry-only Environment signature +
   Role Fn::GetAtt), not from the DeleteSecret policy. The secret-prefix test
   asserts the DeleteSecret grant lands on THAT role, so mis-wiring the grant
   onto another role now fails the test (proven: moving the grant to linkFn
   makes `expect(roleRefs).toContain(role)` fail). No longer a tautology.

2. Pagination comment (cdk/src/constructs/linear-integration.ts): the bounded
   sequence now names the lookup phase (registry lookup → revoke → secret
   delete → optional row purge) and states the lookup scan is the only
   paginating phase, bounded by the registry's tens-of-rows scale.

3. Test prose (cdk/test/handlers/linear-remove-workspace.test.ts): :270 comment
   now says "Page 1: empty (no matching row) + a continuation key" to match
   `Items: []`, and the :262-265 preamble no longer describes stale routeDdb
   Limit behavior. Comment-only; test logic unchanged.

4. Runbook markers (docs/guides/LINEAR_SETUP_GUIDE.md + regenerated Starlight
   mirror): the manual-fallback section now names the durable markers
   (secret_deletion_failed / secret_deletion_error / orphaned_oauth_secret_arn)
   and the SECRET_DELETE_FAILED error code, pointing operators to the
   delete-secret fallback.

Closes aws-samples#306

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(aws-samples#306): reconcile DELETE route with post-aws-samples#854 conventions after rebase

Rebasing onto main (12c9b63) surfaced three collisions with work that
merged after this branch was authored. None were textual conflicts — git
merged the affected files cleanly and produced a build that failed.

1. `allowTestInvoke: false` on the DELETE integration.
   aws-samples#854 stripped the API Gateway console test-invoke Lambda permissions to
   reclaim CloudFormation resources under the 500-resource ceiling, and
   added a guard asserting none are emitted. This route predates that
   convention, so it re-introduced one and failed the guard 6x (once per
   compute_type x enableToolGateway variant). The two sibling routes in
   this same construct already pass the option; now all three agree.

2. Attributed-Lambda count 46 -> 47.
   The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main
   independently reached 46, so `git rebase` dropped the commit as "patch
   contents already upstream" — identical text, different reason. The
   textual change survived; the intent did not. With this branch's extra
   Lambda the correct value is 47.

3. Solution user agent on the new handler's SDK clients.
   linear-remove-workspace.ts constructed `new DynamoDBClient({})` and
   `new SecretsManagerClient({})` directly, which drops the solution user
   agent (aws-samples#319). Now built through makeDocClient()/makeClient(), matching
   linear-link.ts and linear-webhook.ts.

Also resolved the LINEAR_SETUP_GUIDE.md conflict from aws-samples#831: kept its new
"Vault-managed workspaces" section and dropped the duplicated webhook/
uninstall sentence, which this branch had already relocated into the
parent "Removing a workspace" section. Starlight mirror regenerated.

Refs aws-samples#306

* fix(aws-samples#306): report vault-managed teardown as incomplete + settle the revoke race (aws-samples#681 B1, N1-N7, N10-N12)

Addresses PR aws-samples#681 review feedback on `DELETE /v1/linear/workspaces/{slug}`.

B1 (blocking) — the registry lookup could miss an existing workspace.
`ScanCommand` was issued with `Limit: 1` plus a `FilterExpression` on
`workspace_slug`. DynamoDB applies `Limit` to items *examined*, not items
matched, so a filtered scan can legitimately return an empty `Items` array
together with a `LastEvaluatedKey` while the target row sits a page deeper.
On any table with more than one row the handler therefore 404'd on
workspaces that existed. The scan now pages via `ExclusiveStartKey` until
the row is found or the keyspace is exhausted, capped at `MAX_SCAN_PAGES`
(20) so a pathological table cannot pin the Lambda until timeout — the cap
is a 500, not a silent 404, because "we gave up looking" is not "it is not
there". `ConsistentRead: true` was added so a removal issued straight after
a `linear setup` reads its own write.

The paging fix widens, but does not close, a TOCTOU: two concurrent DELETEs
could both find the same `active` row and both report success. The revoke
`UpdateCommand` now carries `ConditionExpression: '#status = :active'`, so
exactly one caller wins; the loser's `ConditionalCheckFailedException` maps
to 404 `WORKSPACE_NOT_FOUND` and, critically, does *not* proceed to delete
the OAuth secret out from under the winner.

N1/N2 — `secret_deleted: boolean` becomes `secret: 'deleted' | 'absent' |
'not_applicable'`. A boolean conflated two very different outcomes: "there
was a secret and it is gone now" and "there was never a Secrets Manager
secret because this workspace is vault-managed". The latter means teardown
is *not* finished — an AgentCore OAuth2 credential provider survives outside
CloudFormation, still holding the Linear client secret and a live,
self-refreshing grant, and `cdk destroy` will not remove it. The response
now echoes `provider_name` for those rows and the CLI prints the exact
`aws bedrock-agentcore-control delete-oauth2-credential-provider` follow-up.
`not_applicable` is deliberately narrow (`providerName && !oauthSecretArn`):
`bgagent linear setup` writes `oauth_secret_arn` unconditionally, so a vault
row that also carries an ARN really did have a secret and reports `absent`.

N3 — the "removes everything" claims in the CLI prompt and the setup guide
were wrong in the vault case. Both now say what is *not* removed, and the
pre-confirmation prompt warns before the destructive action rather than
only disclosing it afterwards.

N7 — the secret delete falls back to the deterministic
`bgagent-linear-oauth-<slug>` name when the row records no
`oauth_secret_arn`, so a partially-written row does not orphan its secret.
`secretsmanager:DeleteSecret` is granted over that name prefix
(`linear-integration.ts`), and `SecretId` accepts a name or an ARN, so the
by-name call is permitted. The prefix is verified identical in all four of
its co-definitions.

N11/N12 — `revoked_reason` is now `admin_removed`, not
`vault_consent_required`. That distinction is load-bearing:
`vault_consent_required` is the one revoked reason the OAuth resolver
re-probes instead of refusing, so reusing it here would let a later
successful vault probe un-latch a workspace an operator deliberately
removed. The vocabulary lives in `LinearRevocationReason` (exported from
`shared/linear-oauth-resolver.ts` as a **type only**) and the writer
declares its own constant. `import type` is erased before esbuild, so the
removal handler takes no runtime dependency on the resolver — a value
import would pull SNS alerting, the resolver's DDB/Secrets Manager clients
and the token-refresh path into this Lambda's bundle, and would land the
handler in the `agent.test.ts` minting-handler census whose entire value is
that such an import is a test failure rather than a production 401.

N4/N5/N6/N10 — `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is read once at module
scope and validated in-handler, so a misconfigured deployment 500s with a
named cause instead of an opaque SDK error; the response body is typed by a
module-local interface applied with `satisfies`; the 404-on-lost-race path
logs a WARN naming `oauth_secret_arn`; the 403 wording no longer implies the
workspace exists.

Not included, per review scope: N8 (409 on duplicate active rows for one
slug — currently first-match-wins) and N9 (collapsing the 403 existence
oracle to 404). Both change API semantics and belong in their own issues
rather than a bugfix PR. N9's wording half is done here.

Tests: 24 in the handler suite (by-name delete, `not_applicable` +
`provider_name` echo, vault-row-with-ARN => `absent`, lost race => 404 with
no secret delete, non-conditional update failure => 500, page cap => 500
after exactly 20 scans, `--purge` delete failure => 500 with the revoke
landed, marker-write failure still surfacing `SECRET_DELETE_FAILED`, and a
missing-table-name case in its own module registry). Full suites green:
cdk 4579/4579 (216 suites), cli 941/941 (63 suites), docs 77 pages,
drift-prevention clean, jira-forge-app 11/11.

The agent pytest step of `//agent:quality` was NOT run: this diff contains
no Python, and that suite writes stray commits when run from a worktree
lacking the aws-samples#856 git-config isolation. `//agent:lint` and `//agent:typecheck`
were run instead, both clean.

Refs aws-samples#306, aws-samples#681.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(aws-samples#306): correct the review-item numbering in 253de07's commit message

Empty commit. No code, test, or docs change — this exists only to correct the
record, because `253de07f`'s message cannot be amended without force-pushing a
shared PR branch.

`253de07f` labels its sections with a B1/N-numbering that does NOT correspond
to review 5181793802 on PR aws-samples#681. Every change it describes is a change that was
actually made, and the descriptions are accurate; only the labels are wrong.
Read the labels below, not the ones in that message.

Root of the confusion: PR aws-samples#681 has had two "B1"s. July's round 4807516158 had
B1 = the `Limit: 1` filtered-Scan bug, fixed back then in `0d006b04`. The
current round 5181793802 has B1 = vault-managed teardown reported as complete.
`253de07f`'s message narrates the already-landed July fix under the current
round's B1 heading, which makes the pagination code read as new work when it
predates the commit.

Authoritative mapping of review 5181793802 to what `253de07f` changed:

  B1  vault-managed teardown reported as complete.
      `secret_deleted: boolean` -> `secret: 'deleted' | 'absent' |
      'not_applicable'`, response echoes `provider_name`, CLI prints the
      `delete-oauth2-credential-provider` follow-up, prompt warns before the
      destructive action, guide updated.
      (253de07's message calls this "N1/N2".)

  N1  `ConsistentRead: true` on the lookup scan + `ConditionExpression:
      '#status = :active'` on the revoke, so the loser of a race 404s and
      never reaches `DeleteSecret`.
      (253de07's message folds this into its "B1" section.)

  N2  pagination-rationale cross-references corrected: dropped
      `jira-webhook-processor.ts`, cited `shared/jira-tenant-registry.ts:32-46`
      as the genuine precedent and as the `ConsistentRead` precedent, kept
      `linear-issue-lookup.ts:131-136` relabelled a counter-example, and
      replaced "paginate to completion" with "until a match or key exhaustion".
      (253de07's message does not label this at all.)

  N3  the `status != 'active'` claim qualified in five places, and the removal
      now writes the distinct `revoked_reason: 'admin_removed'`.
      (253de07's message splits this across its "N3" and "N11/N12".)

  N4  `DeleteSecretCommand` input asserted exactly: `SecretId` plus
      `ForceDeleteWithoutRecovery: true`.
      (253de07's message lists this under "N4/N5/N6/N10" without detail.)

  N5  all three `JSON.stringify(...).toContain('revoked')` assertions replaced
      with value-level `[':revoked'] === 'revoked'` and `[':uid'] === ADMIN`.

  N6  the three untested failure paths covered: revoke rejecting (500, no
      `DeleteSecret`), `markSecretDeletionFailed` rejecting (still
      `SECRET_DELETE_FAILED`), `--purge` `DeleteCommand` rejecting (500 with
      the revoke asserted landed).

  N7  `MAX_SCAN_PAGES = 20`, returning 500 rather than 404.
      (253de07's message uses "N7" for the by-name secret-delete fallback,
      which was not a numbered review item.)

  N8  NOT in this commit. Filed as aws-samples#883 (409 on duplicate active rows).

  N9  wording half only. The behavioural half is filed as aws-samples#884.

  N10 manual-fallback snippet reordered to revoke-first with
      `--condition-expression '#s = :active'`, plus the note that re-running
      `remove-workspace` on an already-removed workspace returns 404.

  N11 module-local `RemoveWorkspaceResponseBody` applied with `satisfies` at
      the return.
      (253de07's message lists this under "N4/N5/N6/N10".)

  N12 `WORKSPACE_REGISTRY_TABLE` is plain `string | undefined`, no non-null
      assertion, validated once in-handler.
      (253de07's message lists this under "N4/N5/N6/N10".)

Also not a numbered review item, and therefore unlabelled rather than
mislabelled: the by-name `bgagent-linear-oauth-<slug>` fallback for the secret
delete when the row records no `oauth_secret_arn`.

The full mapping with reasoning is in the PR comment:
aws-samples#681 (comment)

Refs aws-samples#306, aws-samples#681, aws-samples#883, aws-samples#884.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Sphia Sadek <isadeks@gmail.com>
isadeks added a commit that referenced this pull request Sep 22, 2026
Verification could not tell a workspace's own signing secret from a copy of
another workspace's, because the only available signal was value equality
against the stack-wide secret — and that signal is wrong. A healthy
single-workspace install also holds a secret equal to the stack-wide copy, since
the first install stamps the same real secret into both slots. Rejecting on
equality would 401 exactly the deployments that are safe.

So ownership is now recorded by provenance instead: `webhook_secret_owned` on the
registry row, written from where the value came from. `setup` sets it when the
operator supplied the secret for this workspace or when a stored `lin_wh_` value
differs from the stack-wide one. `update-webhook-secret` sets it too — that is
the remediation path, and without it an operator could paste the correct secret
and see no change in how the workspace is treated.

Only a literal `true` is read back as proof. Absence means "not proven", which is
what every row predating this field is, and is deliberately not the same as
"proven not owned".

`update-webhook-secret` gains `--stack-name` so it can reach the registry, and
fails loudly if the secret write succeeds but the provenance write does not —
silence there would leave the operator believing a fix landed while the state
verification reads is unchanged.

Two things found while building this, both worth knowing:

`mirror-stackwide` — the branch that copies the first workspace's secret into an
additional workspace — is already unreachable from `setup` on current main. #831
added an up-front prompt that throws when neither a supplied nor a legitimately
stored secret exists, which is the only way into that branch. The refusal added
here is therefore a safety net, not a live fix; it is extracted as
`assertMirrorIsSafe` and unit-tested directly, because a guard reachable only
through dead code is otherwise an untested guard. The remaining exposure is rows
written by older releases and not re-run since.

The provenance derivation cannot currently evaluate false, so mutating it to a
literal `true` leaves every test passing. That is semantic equivalence under the
current preconditions rather than a test gap — there is no reachable input that
distinguishes them. It is kept as a derivation because it becomes load-bearing if
that up-front prompt is ever relaxed, and the resulting failure would be silent.
isadeks added a commit to isadeks/sample-autonomous-cloud-coding-agents that referenced this pull request Oct 8, 2026
…ign (aws-samples#879) (aws-samples#880)

* fix(security): merge vault channel_metadata by spread, not Object.assign (aws-samples#879)

`Object.assign` copies via [[Set]], which invokes the `__proto__` setter, so the
pattern sits one refactor away from a prototype-pollution sink. Object spread
defines own properties instead, where the same key lands inert.

Not reachable today — `vaultMetadata` returns a literal with two hard-coded keys —
but semgrep rates javascript.lang.security.insecure-object-assign as Blocking, and
`security:sast` runs whole-repo in the pre-push hook while security-pr.yml runs only
the ranged gates. So this one line passed PR CI on aws-samples#831 and then rejected every
contributor's `git push`, on every branch, for as long as it sat on main.

Spread rather than explicit keyed writes because `vaultMetadata` is the single place
that says which vault fields a task carries; restating them at this call site is the
silent field-drop the helper exists to prevent.

Tests — two source-level guards, failing for different reasons:

* The existing structural check keys off the literal-entry form
  (`linear_workspace_slug: resolved.workspaceSlug,`), so it never fired on this
  builder at all: the one path `vaultMetadata` was written for was the one path its
  own guard never covered. Extended to the assignment form, scoped by dedent because
  an assignment has no literal to terminate.
* A ban on `Object.assign` onto a metadata object. Reverting this change is
  behaviour-preserving, so the existing behavioural test stays green through it — a
  security property that lives in *how* the copy happens needs a structural check.

Mutation-verified: restoring `Object.assign` reds both new guards and neither the
behavioural test; deleting the merge reds the assignment-form guard and the
behavioural test but not the Object.assign ban.

`mise run build` fails locally at `//cdk:synth:quiet` on an unrelated host IAM gap
(`ec2:DescribeAvailabilityZones` denied to the local role, needed for the AgentCore
AZ context lookup). cdk compile, eslint, and 215 suites / 4542 tests pass.

Closes aws-samples#879

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cdk): drop the dead workspace-id write and correct the stale guard docstring (aws-samples#880 review)

Three review items on the handler, all non-blocking.

1. Deleted `channelMetadata.linear_workspace_id = workspaceId;`. It was dead, and this
   PR is what killed it: the object literal above already sets that key to the same
   value from the same variable, and `vaultMetadata` does not emit the key, so the
   spread cannot clobber it either. Confirmed by enumerating every writer of the key,
   not by reading the happy path.

2. Corrected `vaultMetadata`'s docstring. It still claimed the source-level check "keys
   off the object-literal form and cannot see a builder that assigns onto an existing
   object" — the premise of this PR, and false since the assignment-form guard landed.
   It now describes three checks: the literal form, the assignment form, and the
   behavioural one.

3. Trimmed the rationale at the call site from 24 comment lines to 13. Kept the two
   load-bearing claims — spread rather than `Object.assign` because [[Set]] invokes the
   `__proto__` setter, and spread the helper so a newly added vault field cannot be
   silently dropped — plus the recorded-subject note. The guard archaeology and the
   pre-push history now live in the commit trail and aws-samples#879, where they cannot drift out
   of sync with the code.

Refs aws-samples#879.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cdk): close two holes in the vault-metadata source guards (aws-samples#880 review)

Both guards now match code, never prose, via a shared `isCommentLine`, and the
assignment-form guard requires the merge to hit the same object the trigger wrote to.

`assignment-form builders also carry the vault fields`

The review's diagnosis was right: the accept window is far wider than it looks. The
trigger sits at the shallowest indent of `if (WORKSPACE_REGISTRY_TABLE)`, so the scan
ran ~45 lines to the end of that block — a `...vaultMetadata(resolved)` in any later
sibling statement satisfied a builder that merged nothing into its own object.

The suggested remedy is not applied as written, deliberately. Breaking on the first line
indented `<=` the trigger reds the currently passing case on a comment at the same
indent, and more fundamentally the real spread sits one level DEEPER, inside
`if (resolved.providerName)`, because the merge is conditional while the slug write is
not. Same-scope is therefore not an invariant this code can assert; same-object is, and
it closes the same hole. The dedent break is kept as the scope exit and now precedes the
accept check, which is stricter than before — the old order accepted a dedented spread.

`no metadata builder merges via Object.assign`

Regex unqualified over this file. `/Object\.assign\s*\(\s*\w*[Mm]etadata\b/` required the
first argument to be a bare identifier containing "metadata", so `Object.assign(md, …)`
and `Object.assign(row.channelMetadata, …)` reintroduced the sink without redding the
test — `\w*` cannot cross a `.`. Scope stays this one handler on purpose; the
class-level net is the whole-repo semgrep gate that `security-pr.yml` omits, tracked in
aws-samples#235 rather than bolted on here.

Comment-stripping is load-bearing for the vaultMetadata guard, whose `includes()` test
was satisfied by a comment merely mentioning the spread (mutation M3 below). For the
`Object.assign` ban it is defensive rather than required: the handler's three prose
mentions all write the name in backticks, so `\s*` never reaches a `(`. Incidental
punctuation, not a property worth depending on.

Mutation-verified rather than asserted. Each row was applied to the handler, the named
guards observed red, then reverted and the source re-verified by content:

  M1  merge → `Object.assign(channelMetadata, …)`      both guards red
  M2  merge → `Object.assign(md, …)`                   both guards red   (before: BLIND)
  M3  merge deleted, comment still names the spread    assignment guard red (before: BLIND)
  M4  merge moved to a sibling block, other object     assignment guard red (before: BLIND)

M2, M3 and M4 are the differentials — all three passed before this change.

Gates: `//cdk:test` 215 suites / 4552 tests green; this file 61/61; `//cdk:eslint` clean
with no `--fix` mutations; `//cdk:compile` clean; `//cdk:synth:quiet` green once the AZ
context is pinned (it otherwise fails closed on a local role lacking
`ec2:DescribeAvailabilityZones`, as documented in DEPLOYMENT_GUIDE.md — unrelated to
this change).

Note for anyone rerunning: `-t` filtering on this file is unreliable and not a
regression. Two behavioural tests in this describe have no `beforeEach` of their own and
depend on `probeLinearIssueContextMock` being armed by the first describe's, so any
filter that skips it fails them on pristine HEAD too. Run the whole file.

Refs aws-samples#879, aws-samples#235.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: scottschreckengaust <345885+scottschreckengaust@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Sphia Sadek <isadeks@gmail.com>
isadeks added a commit to isadeks/sample-autonomous-cloud-coding-agents that referenced this pull request Oct 12, 2026
… the delivery (aws-samples#917)

* feat(linear): record the owning workspace on project mappings

The Linear project-to-repo mapping table is keyed on `linear_project_id` alone
and carries no workspace attribute, so nothing ties a mapping to the tenant that
owns it. The webhook processor looks the repository up from the body-supplied
`projectId` with no way to check it against the workspace whose signature the
delivery arrived with, which lets any onboarded workspace name another
workspace's project and steer an agent task at that workspace's repository.

This records the missing value so a later change can enforce it; no read path
changes behaviour yet.

`onboard-project` now resolves the owning workspace by asking Linear which
onboarded workspace's own token can see the project, rather than accepting it as
a flag. The resolution is what the enforcement path will check deliveries
against, so a typo'd flag would durably write the cross-tenant mapping the check
exists to prevent — and `organization.id` is read from the same authenticated
response that resolved the project, not from the workspace we assumed we were
asking. `--slug` narrows the search; `--workspace-id` records an owner without
verifying it, for when the Linear API is unreachable.

`backfill-project-workspaces` fills in mappings that predate the field, one pass
per workspace rather than one lookup per row, and paginates the project listing
(`list-projects` stops at 100, which for a backfill would silently leave the
overflow unresolved). The update is conditional on the row still existing and
still having no workspace id, so it neither resurrects a deleted mapping nor
overwrites a concurrent `onboard-project`. A project id claimed by two
workspaces is skipped and named rather than assigned to whichever answered
first.

`platform doctor` reports mappings with no owning workspace, capped at ten named
ids, so an operator can tell whether the backfill is finished before enforcement
is turned on.

* feat(linear): check a project mapping against the workspace that sent the event

The processor resolved the target repository from the body-supplied `projectId`
alone. The mapping table is keyed on that id with no workspace attribute, so a
workspace holding its own valid signing secret could sign an event with its own
`organizationId` while naming another workspace's project, and the processor
would run an agent task on that workspace's repository. Nothing about that
attack needs a shared or stack-wide secret, which is why signature-level fixes
do not reach it.

Now the mapping's `linear_workspace_id` is checked against the event's
`organizationId` and a mismatch drops the delivery.

Dropped rather than answered. The reply would be posted into the sender's own
workspace, and "that project belongs to someone else" both confirms the project
exists and tells a prober the attempt was seen. The log line carries both ids
and is the diagnostic surface instead.

A mapping with no owning workspace recorded still proceeds, and warns. Rejecting
those on deploy would break every install that has not run
`backfill-project-workspaces` yet; turning it into a rejection is a later step,
gated per environment once `platform doctor` reports the backfill clean.

Both rejection tests were confirmed to fail with the check disabled. The
no-workspace-id case originally passed either way — an event with no
`organizationId` never creates a task regardless, because it fails later at
attribution — so it asserts the guard's own log line instead, which is the only
thing that distinguishes a drop here from a drop there.

* feat(linear): record whether a workspace owns its webhook signing secret

Verification could not tell a workspace's own signing secret from a copy of
another workspace's, because the only available signal was value equality
against the stack-wide secret — and that signal is wrong. A healthy
single-workspace install also holds a secret equal to the stack-wide copy, since
the first install stamps the same real secret into both slots. Rejecting on
equality would 401 exactly the deployments that are safe.

So ownership is now recorded by provenance instead: `webhook_secret_owned` on the
registry row, written from where the value came from. `setup` sets it when the
operator supplied the secret for this workspace or when a stored `lin_wh_` value
differs from the stack-wide one. `update-webhook-secret` sets it too — that is
the remediation path, and without it an operator could paste the correct secret
and see no change in how the workspace is treated.

Only a literal `true` is read back as proof. Absence means "not proven", which is
what every row predating this field is, and is deliberately not the same as
"proven not owned".

`update-webhook-secret` gains `--stack-name` so it can reach the registry, and
fails loudly if the secret write succeeds but the provenance write does not —
silence there would leave the operator believing a fix landed while the state
verification reads is unchanged.

Two things found while building this, both worth knowing:

`mirror-stackwide` — the branch that copies the first workspace's secret into an
additional workspace — is already unreachable from `setup` on current main. aws-samples#831
added an up-front prompt that throws when neither a supplied nor a legitimately
stored secret exists, which is the only way into that branch. The refusal added
here is therefore a safety net, not a live fix; it is extracted as
`assertMirrorIsSafe` and unit-tested directly, because a guard reachable only
through dead code is otherwise an untested guard. The remaining exposure is rows
written by older releases and not re-run since.

The provenance derivation cannot currently evaluate false, so mutating it to a
literal `true` leaves every test passing. That is semantic equivalence under the
current preconditions rather than a test gap — there is no reachable input that
distinguishes them. It is kept as a derivation because it becomes load-bearing if
that up-front prompt is ever relaxed, and the resulting failure would be silent.

* feat(linear): refuse a signing secret that cannot attest which workspace sent a delivery

The processor routes from identifiers in the request body, so verification has to
establish WHICH tenant sent a delivery — not merely that the sender knows a secret
this stack accepts. Two secrets fail that test: the stack-wide one, which is bound
to no workspace, and a per-workspace one that is a copy of another workspace's,
which older releases could install.

Both are now refused, and both only where they can actually cross a boundary:

- `verifyLinearRequestForWorkspace` gains a `shared-secret` outcome. A matching
  signature on a row that does not record the secret as its own is rejected when
  another workspace is active. Fatal like `mismatch`, for the same reason —
  falling through would re-admit it via the stack-wide path.
- The receiver skips the stack-wide fallback entirely on a multi-workspace stack.
  This is what supersedes sole-tenant binding rather than implementing it: with
  the fallback gone under multi-tenancy, that path survives only where there is
  one tenant, and there it cannot name the wrong one.
- `verified_via_stack_wide` is forwarded to the processor, which replaces the
  body's `organizationId` with the sole active workspace and drops the delivery
  when there is no single answer. The field is overwritten on the payload rather
  than threaded as a parameter because the workspace id is read from about six
  places downstream; passing it alongside would leave every one of them a site
  where the claimed value could still be picked up.

Single-workspace installs are untouched by design. Every check consults the active
workspace count first, and with one tenant a tenant-less secret still identifies
the only tenant there is. That also makes an absent provenance flag — the state of
every row written before it existed — harmless on the installs where it is normal.

`countActiveLinearWorkspaces` saturates at 2, since no caller needs the true
total, and answers 1 when the registry cannot be read. That is deliberately the
permissive direction: these checks harden a signature that has already verified,
so a DynamoDB throttle must not become a stack-wide outage.

No IAM change. `grantReadData` already covers `dynamodb:Scan` for both the
receiver and the processor on the registry table.

All three guards were confirmed to fail with the check disabled. One test needed
fixing first: the multi-workspace fallback case passed because the harness shrank
the workspace count along with the absent registry row, so the receiver had only
ever seen one workspace.

* feat(linear): record provenance for secrets that were never mirrored

Enforcing per-workspace secret ownership needs the provenance flag to already be
set, and it is set only by `setup` and `update-webhook-secret`. On an existing
multi-workspace stack no row has it, so turning enforcement on rejects every
workspace at once — including workspaces that genuinely own their secret and are
being broken for nothing. On the dev stack that is 3 of 3.

`backfill-secret-provenance` closes the gap for the workspaces where ownership can
actually be established. `mirror-stackwide` can copy nothing except the stack-wide
value, so a stored secret that DIFFERS from it was never mirrored and is therefore
owned. That conclusion cannot be wrong, which is what makes it safe to write.

The converse is deliberately not used. A secret EQUAL to the stack-wide value is
equally consistent with a healthy single-workspace install, whose first `setup`
stamps the same real secret into both slots — so `inherited` withholds a verdict
rather than asserting "not owned", and only `own` is recorded. This is the same
asymmetry the earlier commit's doc comment warns about, now expressed as code: the
direction that grants is sound, the direction that would reject is not.

Reading the stack-wide secret is fatal on failure rather than treated as "not set".
Continuing with undefined would classify every workspace as owning its secret,
since nothing can equal a value that was never fetched — recording exactly the
shared secrets this command exists to withhold.

`platform doctor` reports the same population, gated on the active workspace count
so it matches the enforcement path: on a single-workspace stack an unrecorded
provenance is the normal state for every pre-existing row and carries no risk, so
it passes with a note rather than warning.

Both directions of the classifier were confirmed to fail when mutated — treating
`equals stack-wide` as owned, and treating an empty stored secret as present.

* fix(linear): do not blame stale mappings for a workspace we could not query

The first live run of `backfill-project-workspaces` hit two workspaces whose
Linear grants had expired. Both returned 401, so every mapping they own came back
unresolved — and the report concluded those mappings "name projects no onboarded
workspace can see" and told the operator to re-onboard or remove them. Four valid
rows, and the suggested remedy was to delete them.

An unresolved mapping means opposite things depending on whether every workspace
was actually reachable, so the conclusion now branches on that. Skipped workspaces
are tracked wherever the loop gives up on one — no token, an API error, or a
response with no organization id — and when any exist the report leads with "Do
NOT delete these yet", names them, and points at restoring their access first.
The delete-them wording now appears only when every workspace was queried
successfully.

Verified by re-running against the dev stack, where the output now names
demo-abca and maguireb as the reason rather than implicating their projects.

The guard is asserted at source level: the branch sits inside a Commander action
that would need the whole AWS and Linear surface stood up to reach, and the
behavioural proof is the live run. The test pins that the unconditional wording
cannot come back.

* fix(linear): refuse to half-repair a workspace when the registry cannot be found

`update-webhook-secret` writes two things: the signing secret, and the registry
flag recording that the workspace owns it. Verification reads the flag, so without
it the repair changes nothing observable — the workspace keeps 401ing.

The registry write was wrapped in `if (registryTableName)`, so an operator who
defaulted or mistyped `--stack-name` got the secret written, no flag, and a green
"✅ Updated webhook signing secret". That is precisely the silent-miss the comment
directly above the block claims to prevent, and it would send someone chasing a
401 whose cause was in the output they already saw.

Now it refuses, names the stack it looked in, says the secret was preserved, and
points at `--stack-name`.

* refactor(linear): drop migration tooling for a state one deployment is in

The fix itself is 257 lines of cdk/src. The CLI around it had grown to 926 —
mostly tooling to migrate stacks into the new model. Reviewed against how many
deployments actually need migrating, two pieces did not earn their weight.

`backfill-project-workspaces` is gone, with its paginated project lister. A
single-workspace install does not need it at all: with one tenant every mapped
project belongs to that tenant, so a body-supplied projectId cannot steer a task
across a boundary. Its real audience was "a stack with two or more workspaces AND
pre-existing mappings", which is one deployment today. It was also the only part
of the branch that needed live Linear tokens, and the only part that has already
produced a defect — a report that told an operator to delete four valid mappings
because two workspaces had expired grants.

The doctor check that reported those mappings stays, because knowing about them
is still worth an operator's attention. Its remedy is now "re-run onboard-project
before you add a second workspace", and it says plainly that the state is harmless
while one workspace is active.

`assertMirrorIsSafe` is gone too. It guarded a branch that is unreachable from
`setup`, and the value that branch would produce is no longer trusted on its own
anyway — the receiver rejects a secret not recorded as the workspace's own once a
second workspace is active. The comment there now describes what the code does
rather than the refusal that used to follow it, and says what would need to change
if the up-front prompt is ever relaxed.

`backfill-secret-provenance` stays. This is the one place doing it by hand is
actively dangerous rather than merely tedious: the safe rule holds in one
direction only, and setting the flag on a workspace that holds the shared secret
silently reopens the hole. It also runs with no Linear dependency at all, which is
why it is the piece that already works on a stack with two dead grants.

935 tests green. The classifier's mutation was re-run after the removal, since it
sat between two deleted blocks and had to be restored.

* test(linear): note why the fake UUIDs avoid twelve trailing digits

The org-id fixtures use hex letters in the final group because a UUID ending in
twelve consecutive digits matches gitleaks' `aws-account-id` rule, which failed
CI's range-scoped secret scan on values that hold no secret.

The values themselves were corrected in place across this branch's history —
fixing them in a follow-up commit left them in the commit that introduced the
file, and the scan is range-scoped over every commit in the PR. Recorded as a
comment so the constraint survives the next person editing these constants.

Fixed in the fixture rather than allowlisted in `.gitleaks.toml`: widening a
shared security rule to accommodate a test constant is the wrong direction.

* feat(linear): scope @bgagent comment targets to the sending workspace

Comment triggers find their target by issue id, through the orchestration
or the newest task on the issue. Match that target's recorded workspace
(the orchestration's credentials_ref, or the task's
channel_metadata.linear_workspace_id) against the workspace the delivery
came from, and act only when they match. This brings the comment path in
line with the project-mapping workspace check added earlier on this
branch.

The task lookup now returns linear_workspace_id from channel_metadata,
which the LinearIssueIndex projection already includes. Test fixtures
now use a single workspace throughout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(linear): end-to-end coverage for webhook workspace binding

Runs a delivery through the real receiver and hands the receiver's
processor invoke to the real processor, which runs the real token
resolver, user lookup, orchestration store and createTaskCore. All
tables and secrets live in one stateful fake shared by both handlers, so
the receiver's workspace count and the processor's binding read the same
registry. Only the modules that call Linear's API are mocked, and network
access is refused.

Each case asserts whether a task record was persisted, and for which
repository, platform user and workspace. Covered:

- several workspaces: signature and workspace rules for owned,
  stack-wide-only, shared and unregistered secrets, a tampered body,
  a revoked workspace, project ownership, and actor scoping
- one workspace: the stack-wide fallback binds to the only workspace;
  lingering mappings from inactive workspaces are not served; a second
  workspace onboarded inside the receiver's count-cache window is caught
  by the processor
- @bgagent comments on epics, sub-issues, plain issues and retries act
  only on the sending workspace's work, with matching-workspace controls

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): make doctor's Linear verdicts match the workspace count and admin removals

Found while live-testing this branch on a multi-workspace stack:

- An admin-removed workspace (`remove-workspace`, revoked_reason=admin_removed)
  was reported as a REVOKED authorization: doctor failed and told the operator
  to re-authorize a workspace they had removed on purpose. It is now `disabled`,
  the state that already exists for intentional drops, and the pass line no
  longer counts it as authorized.
- The unowned-project-mapping check said "harmless while one workspace is
  active" whatever the count. With more than one active workspace the webhook
  path admits an unowned mapping with only a warning, so the check now reads
  the registry: it fails above one workspace, keeps the "harmless" wording for
  exactly one, and makes no claim when the registry cannot be read.
- A processor comment named `backfill-project-workspaces`, which this branch
  removed; it now names `onboard-project`.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bgagent <bgagent@noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adapters Third-party integrations: Linear, Slack, GitHub App, notification/deploy providers enhancement New feature or request security Cedar/HITL, IAM least-privilege, secrets, PII/DLP, guardrails, supply-chain/CVE v1 Version 1

Projects

None yet

5 participants